Skip to content

feat(plugin): per-plugin KV storage#1187

Open
mvanhorn wants to merge 5 commits intofloatpane:masterfrom
mvanhorn:feature/510-plugin-persistent-storage
Open

feat(plugin): per-plugin KV storage#1187
mvanhorn wants to merge 5 commits intofloatpane:masterfrom
mvanhorn:feature/510-plugin-persistent-storage

Conversation

@mvanhorn
Copy link
Copy Markdown
Contributor

@mvanhorn mvanhorn commented Apr 28, 2026

What?

  • Add matcha.store_set(key, value), store_get(key), store_delete(key), store_keys() to the Lua plugin API
  • Storage lives at ~/.config/matcha/plugins/<plugin_name>/data.json with mode 0o600, JSON-encoded, atomic write via unique temp file + Rename
  • Per-plugin scoping: Manager tracks the active plugin during plugin load, hook invocation, and keybinding callbacks; hooks/keybindings now carry their plugin attribution (registeredHook{fn, plugin})
  • Plugin-name validation: only ^[a-zA-Z0-9_-]+$ is accepted as a path component, blocking traversal
  • Lua API surfaces real store-init errors (corrupt JSON, permission denied) to plugin authors instead of swallowing them as "no plugin context"
  • Tests cover: round-trip set/get/delete/keys, persistence across Manager instances, concurrent writes, file mode 0o600 (including survives overwrite), invalid plugin name rejection, Lua-level error propagation on corrupt JSON, hook/keybinding plugin attribution
  • Demo helper: screenshots/cmd/plugin_storage_demo/main.go loads the same plugin in two Manager instances against the same HOME to prove cross-session persistence

demo

Simulated demo (Remotion) — matcha theme, scripted UI, not a live capture.

Why?

This is the maintainer-spec from issue #510:

Plugins have no way to persist data between sessions. Plugins like the AI rewrite plugin can't save API keys, user preferences, or cached data without relying on external HTTP calls. Each session starts with a blank slate.

The four function names, JSON-per-plugin file format, and config-dir path all match the spec verbatim:

local matcha = require("matcha")
matcha.store_set("api_key", "sk-...")
local key = matcha.store_get("api_key")

#510 is the gating issue for the broader plugin-API cluster (#511 hooks, #512 lifecycle, #513 account info, #514 multi-field prompts) - persistent storage is the most-requested missing piece for the existing 35-plugin marketplace.

Notes

The implementation extends the per-plugin attribution model so future plugin APIs (account info, lifecycle hooks) can reuse the same currentPlugin plumbing in Manager.

Closes #510.

This contribution was developed with AI assistance.

Adds matcha.store_set, store_get, store_delete, store_keys to the Lua
plugin API per floatpane#510.

Per-plugin scoping is preserved by tracking the active plugin context
during plugin load, hook invocation, and keybinding callbacks. The
manager attributes hooks/keybindings to their owning plugin so the
storage API resolves to ~/.config/matcha/plugins/<plugin>/data.json
even when multiple plugins call the same API.

- plugin/storage.go: pluginStore (mutex + atomic write + 0o600 mode)
- plugin/api.go: register store_set / store_get / store_delete / store_keys
- plugin/plugin.go: currentPlugin tracking, KeyBinding plugin attribution
- plugin/hooks.go: registeredHook captures plugin attribution; CallHook
  swaps currentPlugin per callback so isolation holds across plugins
- plugin/storage_test.go: round-trip, persistence, concurrent writes,
  file mode 0o600
- plugin/api_storage_test.go: Lua-level integration + cross-plugin
  isolation
- plugin/README.md: persistent storage section with the issue example

Closes floatpane#510.
Loads a small Lua plugin twice in two separate Manager instances against
the same HOME to prove the new store_set/store_get/store_delete/store_keys
API persists across sessions. Captures the run as
public/assets/plugin_storage_demo.gif (mode 0o600 visible at the bottom of
the GIF).
- flush(): unique tmp file via os.CreateTemp + explicit Chmod(0600) before
  rename, preventing collision when two pluginStore instances target the
  same plugin and ensuring 0600 mode survives overwrite
- newPluginStore: reject plugin names that aren't [a-zA-Z0-9_-]+, blocking
  path traversal via crafted plugin names
- currentStore now returns (*pluginStore, error); Lua API distinguishes
  missing plugin context from real store init failures and surfaces the
  underlying error to the plugin author
- tests: cover overwrite mode preservation, invalid name rejection, and
  Lua-level error propagation on store init failure
@mvanhorn mvanhorn requested a review from a team as a code owner April 28, 2026 15:33
@github-actions github-actions Bot added bug Something isn't working enhancement New feature or request labels Apr 28, 2026
public/assets/plugin-storage-remotion-demo.gif walks through the API
surface (store_set/store_get/store_delete/store_keys), session 1 writes,
session 2 with a fresh Manager retrieving persisted values, and the
final data.json on disk with mode 0o600. Rendered by Remotion at
1280x720, encoded via --scale=0.6 --every-nth-frame=3.
Tests use t.Setenv("HOME", t.TempDir()) for isolation, but
os.UserHomeDir() reads %USERPROFILE% on Windows, not $HOME.
Result: every test in plugin/ shared the same real Windows
profile directory and bled state into each other.

Add setTestHome() helper that sets both HOME and USERPROFILE,
and replace the inline Setenv calls. Guard the file-mode
assertions in TestPluginStoreFileMode and the Overwrite variant
behind runtime.GOOS != "windows" since NTFS does not honor
Unix permission bits the same way (0o600 reads back as 0o666).

Verified: go test ./plugin/... passes locally on macOS.
@mvanhorn
Copy link
Copy Markdown
Contributor Author

Pushed 138272f. The Windows failures came down to t.Setenv("HOME", t.TempDir()) not isolating anything on Windows because os.UserHomeDir() reads %USERPROFILE%, not $HOME, so every test in the package shared the real Windows profile and bled state. Added a setTestHome helper that sets both, and guarded the 0o600 mode assertions on Windows since NTFS does not honor Unix permissions the same way. go test ./plugin/... passes locally on macOS.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FEAT: Plugin API - persistent storage

1 participant